376 lines
20 KiB
Python
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()
|