"""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()