Files
customerTicket/tests/test_payment_links.py
T
2026-10-07 13:39:29 +03:30

376 lines
20 KiB
Python

"""Permanent regressions for SMS payment capabilities; no real SMS is sent."""
import uuid
import re
from datetime import timedelta
from urllib.parse import urlsplit
import pytest
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import TicketMagicLink, User
from apps.core.models import SiteBranding
from apps.notifications.models import NotificationJob, SMSTemplate
from apps.payments import services
from apps.payments.access import SESSION_KEY, issue_payment_link, token_digest
from apps.payments.models import PaymentAccessLink, PaymentNotification, PaymentReceipt
from apps.payments.notifications import send_payment_sms
from apps.integrations.sms.providers import SMSResult
from tests.test_payments import case, data, upload, submit
pytestmark = pytest.mark.django_db
def open_notice(client, case):
link, url = issue_payment_link(notice=case['notice'])
response = client.get(urlsplit(url).path, follow=True)
assert response.status_code == 200
return link
def test_customer_shares_short_link_once_without_sending_sms(case, client):
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
client.force_login(case['customer'])
jobs = NotificationJob.objects.count()
assert client.get(endpoint).status_code == 405
response = client.post(endpoint)
assert response.status_code == 200
assert response['Cache-Control'] == 'private, no-store'
url = response.json()['url']
assert re.fullmatch(r'/p/[a-z0-9]{10}/', urlsplit(url).path)
assert client.post(endpoint).json()['url'] == url
assert PaymentAccessLink.objects.count() == 1
assert NotificationJob.objects.count() == jobs
recipient = Client()
page = recipient.get(urlsplit(url).path, follow=True)
assert page.status_code == 200
assert case['notice'].number in page.content.decode()
assert '_auth_user_id' not in recipient.session
assert recipient.post(endpoint).json()['url'] == url
def test_anonymous_share_reuses_original_expiry_and_revocation(case, client):
link, url = issue_payment_link(notice=case['notice'])
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
client.get(urlsplit(url).path, follow=True)
before = link.expires_at
assert client.post(endpoint).json()['url'] == url
link.refresh_from_db()
assert link.expires_at == before
assert PaymentAccessLink.objects.count() == 1
link.revoked_at = timezone.now()
link.save(update_fields=['revoked_at'])
assert client.post(endpoint).status_code == 410
assert PaymentAccessLink.objects.count() == 1
def test_share_link_checks_owner_scope_and_csrf(case, client):
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
other = User.objects.create_user(username='other-share-owner', account_type='CUSTOMER')
client.force_login(other)
assert client.post(endpoint).status_code == 404
client.force_login(case['creator'])
assert client.post(endpoint).status_code == 403
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(case['customer'])
assert csrf_client.post(endpoint).status_code == 403
open_notice(client := Client(), case)
outside = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'اعلامیه خارج از لینک اشتراک', 'amount': 100, 'deposit_id': '00044'})
assert client.post(reverse('payments:notice_share_link', args=[outside.public_id])).status_code == 404
@pytest.mark.parametrize('with_identifier', [False, True])
def test_full_copy_mentions_required_identifier_only_when_present(case, client, with_identifier):
from apps.payments.models import PaymentNotice
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳' if with_identifier else '')
client.force_login(case['customer'])
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
text = response.context['notice'].copy_text
assert ('وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in text) == with_identifier
assert ('شناسه واریز: 123' in text) == with_identifier
assert 'شماره حساب: 000123456' in text
assert 'کپی لینک کوتاه این صفحه برای ارسال به دیگران' in response.content.decode()
def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settings):
settings.PAYMENT_LINK_TTL_DAYS = 3
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
class Provider:
def send_message(self, mobile, text):
return SMSResult(True, 'test-provider')
send_payment_sms(job, provider=Provider())
notice_sms = PaymentNotification.objects.get(job=job)
url = notice_sms.context['payment_link']
token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1]
assert urlsplit(url).path == f'/p/{token}/'
assert re.fullmatch(r'[a-z0-9]{10}', token)
assert url in notice_sms.rendered_body
assert notice_sms.access_link.token_hash == token_digest(token)
assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2)
first_body = notice_sms.rendered_body
send_payment_sms(job, provider=Provider())
notice_sms.refresh_from_db()
assert notice_sms.rendered_body == first_body and PaymentAccessLink.objects.count() == 1
for _ in range(2):
response = client.get(urlsplit(url).path, follow=True)
assert response.status_code == 200
assert case['notice'].deposit_id in response.content.decode()
assert '_auth_user_id' not in client.session
notice_sms.access_link.refresh_from_db()
assert notice_sms.access_link.view_count == 2
def test_short_payment_link_retries_collision_without_breaking_transaction(case, client, monkeypatch):
tokens = iter(['ac26i9raoz', 'ac26i9raoz', 'lfp694raup'])
monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: next(tokens))
first, _ = issue_payment_link(notice=case['notice'])
second, url = issue_payment_link(notice=case['notice'])
assert first.pk != second.pk
assert urlsplit(url).path == '/p/lfp694raup/'
assert PaymentAccessLink.objects.count() == 2
assert client.get('/p/lfp694raup/', follow=True).status_code == 200
def test_payment_link_collision_retries_are_bounded(case, monkeypatch):
monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: 'ac26i9raoz')
issue_payment_link(notice=case['notice'])
with pytest.raises(RuntimeError, match='لینک یکتای پرداخت'):
issue_payment_link(notice=case['notice'])
assert PaymentAccessLink.objects.count() == 1
def test_legacy_long_payment_link_keeps_scope_and_expiry(case, client):
token = 'x_NAEA8ptOXu5msNYgNNpCM8vyWh6_VAhELPs5q8C2U'
link = PaymentAccessLink.objects.create(customer=case['customer'], notice=case['notice'],
token_hash=token_digest(token), expires_at=timezone.now() + timedelta(days=1))
path = reverse('payments:open_link', args=[token])
response = client.get(path, follow=True)
assert response.status_code == 200
assert client.session[SESSION_KEY] == link.pk
assert '_auth_user_id' not in client.session
PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
assert client.get(path).status_code == 410
@pytest.mark.parametrize('via_link', [False, True])
def test_receipt_picker_accepts_gallery_images_without_camera_capture(case, client, via_link):
if via_link:
open_notice(client, case)
else:
client.force_login(case['customer'])
response = client.get(reverse('payments:receipt_new'), {'notice': case['notice'].public_id})
assert response.status_code == 200
widget = response.context['form'].fields['receipt_image'].widget
assert widget.attrs['accept'] == 'image/*'
assert 'capture' not in widget.attrs
assert 'accept="image/*"' in response.content.decode()
def test_public_notice_preserves_copy_warning_and_database_signature(case, client):
branding = SiteBranding.load()
branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس'
branding.save()
open_notice(client, case)
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
html = response.content.decode()
assert 'data-copy="123"' in html
assert 'کپی کامل اطلاعات' in html and 'pay-deposit-required' in html
assert case['account'].name in html
assert 'نام حساب: ' + case['account'].name in response.context['notice'].copy_text
assert 'واریز بدون این شناسه معتبر نخواهد بود' in html
assert html.count(branding.developer_credit_text) == 1
assert 'no-store' in response['Cache-Control'] and response['X-Robots-Tag'] == 'noindex, nofollow'
assert reverse('accounts:profile') not in html
@pytest.mark.parametrize('linked', [True, False])
@pytest.mark.django_db(transaction=True)
def test_anonymous_link_can_submit_linked_and_independent_receipts(case, client, linked):
link = open_notice(client, case)
values = data(case, linked=linked, deposit_id='')
response = client.post(reverse('payments:receipt_new'), {**values, 'receipt_image': upload()})
assert response.status_code == 302
receipt = PaymentReceipt.objects.get()
assert receipt.customer == case['customer'] and receipt.submitted_by == case['customer']
assert receipt.notice_id == (case['notice'].pk if linked else None)
assert link.submitted_receipts.filter(pk=receipt.pk).exists()
assert client.get(response.url).status_code == 200
image = client.get(reverse('payments:receipt_image', args=[receipt.public_id]))
assert image.status_code == 200 and 'no-store' in image['Cache-Control']
assert b''.join(image.streaming_content)
# A browser retry of the same upload is idempotent.
assert client.post(reverse('payments:receipt_new'), {**values, 'receipt_image': upload()}).status_code == 302
assert PaymentReceipt.objects.count() == 1
assert '_auth_user_id' not in client.session
def test_scope_blocks_other_customers_and_other_notices_of_same_customer(case, client):
unrelated = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'اعلامیه خارج از لینک', 'amount': 100, 'deposit_id': '1'})
another = User.objects.create_user(username='link-other-customer', account_type='CUSTOMER')
foreign = services.issue_notice(case['creator'], {'customer': another, 'account': case['account'],
'subject': 'خصوصی مشتری دیگر', 'amount': 100, 'deposit_id': '2'})
open_notice(client, case)
for notice in [unrelated, foreign]:
assert client.get(reverse('payments:notice_detail', args=[notice.public_id])).status_code == 404
assert client.get(reverse('payments:receipt_new'), {'notice': notice.public_id}).status_code == 404
response = client.post(reverse('payments:receipt_new'), {**data(case, notice_id=notice.pk), 'receipt_image': upload()})
assert response.status_code == 200 and 'notice_id' in response.context['form'].errors
assert not PaymentReceipt.objects.exists()
assert client.get(reverse('payments:home')).status_code == 302
assert client.get(reverse('payments:notices')).status_code == 302
form = client.get(reverse('payments:receipt_new')).context['form']
assert list(form.fields['notice_id'].queryset.values_list('pk', flat=True)) == [case['notice'].pk]
@pytest.mark.parametrize('invalidator', ['expired', 'revoked', 'inactive'])
def test_expiry_revocation_and_inactive_customer_apply_to_existing_session(case, client, invalidator):
link = open_notice(client, case)
if invalidator == 'expired':
PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1))
elif invalidator == 'revoked':
PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now())
else:
User.objects.filter(pk=case['customer'].pk).update(is_active=False)
response = client.post(reverse('payments:receipt_new'), {**data(case), 'receipt_image': upload()})
assert response.status_code == 410 and not PaymentReceipt.objects.exists()
assert SESSION_KEY not in client.session
def test_invalid_link_and_uuid_are_not_public_capabilities(case, client):
assert client.get(reverse('payments:open_link', args=['not-a-token'])).status_code == 410
assert client.get(reverse('payments:open_link', args=['A' * 43])).status_code == 410
assert client.get(reverse('payments:notice_detail', args=[case['notice'].public_id])).status_code == 302
def test_wrong_account_requires_explicit_confirmation(case, client):
_, url = issue_payment_link(notice=case['notice'])
client.force_login(case['outsider'])
path = urlsplit(url).path
assert client.get(path).status_code == 200
assert '_auth_user_id' in client.session and SESSION_KEY not in client.session
assert client.post(path, {'action': 'logout_and_open', 'account_id': 'wrong'}).status_code == 200
assert client.post(path, {'action': 'logout_and_open', 'account_id': case['outsider'].pk}).status_code == 302
assert '_auth_user_id' not in client.session and SESSION_KEY in client.session
def test_link_forms_keep_csrf_and_field_errors(case):
client = Client(enforce_csrf_checks=True)
open_notice(client, case)
url = reverse('payments:receipt_new')
assert client.post(url, {'paid_amount': '100'}).status_code == 403
client.get(url)
token = client.cookies['csrftoken'].value
response = client.post(url, {'csrfmiddlewaretoken': token, 'paid_amount': '۱۲۳٬۴۵۶', 'receipt_mode': 'independent', 'submission_key': uuid.uuid4()})
assert response.status_code == 200 and response.context['form'].errors
assert response.context['form']['paid_amount'].value() == '۱۲۳٬۴۵۶'
def test_sms_allowed_tags_follow_the_event_even_without_model_clean(case):
template = SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED', unit__isnull=True)
template.allowed_tags = ['magic_link']
template.save(update_fields=['body'])
template.refresh_from_db()
assert 'payment_link' in template.allowed_tags and 'magic_link' not in template.allowed_tags
assert '{payment_link}' in template.supported_tags
assert 'magic_link' in SMSTemplate.tags_for_event('STAFF_REPLY')
template.body = '{magic_link}'
with pytest.raises(ValidationError):
template.clean()
template.body = '{payment_link'
with pytest.raises(ValidationError):
template.clean()
def test_approval_sms_link_opens_the_correct_receipt_without_login(case, client):
receipt = submit(case)
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE')
services.decide_receipt(case['second'], receipt.public_id, 1, 'APPROVE')
job = NotificationJob.objects.get(event_type='PAYMENT_RECEIPT_APPROVED')
class Provider:
def send_message(self, mobile, text):
return SMSResult(True, 'approval-test')
send_payment_sms(job, provider=Provider())
notification = PaymentNotification.objects.get(job=job)
assert notification.access_link.receipt_id == receipt.pk
response = client.get(urlsplit(notification.context['payment_link']).path, follow=True)
assert response.status_code == 200 and receipt.number in response.content.decode()
assert '_auth_user_id' not in client.session
assert 'ثبت تأیید من' not in response.content.decode()
def test_receipt_scope_images_revocation_and_rejected_revision(case, client):
receipt = submit(case)
another_notice = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'خارج از دسترسی', 'amount': 100, 'deposit_id': '3'})
form_data = {**data(case, notice_id=another_notice.pk), 'receipt_image': upload()}
from apps.payments.forms import ReceiptForm
form = ReceiptForm(form_data, {'receipt_image': form_data.pop('receipt_image')}, customer=case['customer'])
assert form.is_valid()
unrelated = services.submit_receipt(case['customer'], case['customer'], form.cleaned_data)
link = open_notice(client, case)
assert client.get(reverse('payments:receipt_detail', args=[unrelated.public_id])).status_code == 404
assert client.get(reverse('payments:receipt_image', args=[unrelated.public_id])).status_code == 403
assert client.post(reverse('staff_payments:decision', args=[receipt.public_id]), {'version': 1, 'action': 'APPROVE'}).status_code == 302
receipt.refresh_from_db()
assert receipt.status == 'PENDING'
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خواناتر ارسال شود')
revise = reverse('payments:receipt_revise', args=[receipt.public_id])
assert client.get(revise).status_code == 200
values = {**data(case), 'receipt_image': upload(), 'version': 1}
assert client.post(revise, values).status_code == 302
receipt.refresh_from_db()
assert receipt.current_version == 2 and receipt.status == 'PENDING'
PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now())
assert client.get(reverse('payments:receipt_image', args=[receipt.public_id])).status_code == 410
def test_cached_unsent_sms_is_upgraded_without_rewriting_custom_text(case):
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
notification = PaymentNotification.objects.get(job=job)
old_url = notification.context['payment_link']
notification.rendered_body = 'متن محفوظ قبل و بعد ' + old_url
notification.save(update_fields=['rendered_body'])
class Provider:
def send_message(self, mobile, text):
return SMSResult(True, 'upgrade-test')
send_payment_sms(job, provider=Provider())
notification.refresh_from_db()
assert notification.access_link_id
assert notification.rendered_body == 'متن محفوظ قبل و بعد ' + notification.context['payment_link']
def test_staff_can_issue_a_fresh_copyable_link_without_sending_sms(case, client):
client.force_login(case['creator'])
url = reverse('staff_payments:notice_payment_link', args=[case['notice'].public_id])
assert client.get(url).status_code == 405
response = client.post(url, follow=True)
assert response.status_code == 200
share_url = response.context['payment_share_url']
assert f'data-copy="{share_url}"' in response.content.decode()
assert PaymentAccessLink.objects.count() == 1
assert NotificationJob.objects.filter(event_type='PAYMENT_NOTICE_CREATED').count() == 1
client.logout()
assert client.get(urlsplit(share_url).path, follow=True).status_code == 200
def test_submission_service_enforces_link_scope_and_validity(case):
from apps.payments.forms import ReceiptForm
link, _ = issue_payment_link(notice=case['notice'])
another = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
'subject': 'خارج از لینک', 'amount': 100, 'deposit_id': '4'})
form = ReceiptForm(data(case, notice_id=another.pk), {'receipt_image': upload()}, customer=case['customer'])
assert form.is_valid()
with pytest.raises(PermissionDenied):
services.submit_receipt(case['customer'], case['customer'], form.cleaned_data, access_link=link)
PaymentAccessLink.objects.filter(pk=link.pk).update(revoked_at=timezone.now())
independent = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
assert independent.is_valid()
with pytest.raises(PermissionDenied):
services.submit_receipt(case['customer'], case['customer'], independent.cleaned_data, access_link=link)
assert not PaymentReceipt.objects.exists()