Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -1,3 +1,54 @@
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
# Create your tests here.
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from .models import DiscountCode
|
||||
|
||||
|
||||
class ValidateDiscountPermissionTests(TestCase):
|
||||
"""
|
||||
🔒 اثبات فیکسِ امنیتی: ValidateDiscountView قبلاً permission_classes
|
||||
نداشت (یعنی دیفالتِ پروژه AllowAny اعمال میشد) و یک کلاینتِ بدونِ
|
||||
احرازهویت میتونست بینهایت کد رو probe کنه و valid/type/value رو
|
||||
ببینه — درحالیکه RedeemDiscountView (اندپوینتِ واقعیِ مصرف) از قبل
|
||||
IsAuthenticated داره. این تست هم رفتارِ ۴۰۱ برای anonymous، هم رفتارِ
|
||||
صحیحِ اعتبارسنجی برای کاربرِ لاگینکرده رو تضمین میکنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-validate-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
self.discount = DiscountCode.objects.create(
|
||||
code='SUMMER10', type='percent', value=10, max_uses=5, expires_at=None,
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_rejected(self):
|
||||
response = self.client.get('/api/discount/validate', {'code': self.discount.code})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_authenticated_request_validates_usable_code(self):
|
||||
response = self.client.get(
|
||||
'/api/discount/validate',
|
||||
{'code': self.discount.code},
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['valid'])
|
||||
self.assertEqual(response.data['type'], 'percent')
|
||||
self.assertEqual(response.data['value'], 10)
|
||||
|
||||
def test_authenticated_request_reports_unusable_code_as_invalid(self):
|
||||
expired = DiscountCode.objects.create(
|
||||
code='EXPIRED5', type='fixed', value=5000, max_uses=5,
|
||||
expires_at=timezone.now() - timezone.timedelta(days=1),
|
||||
)
|
||||
response = self.client.get(
|
||||
'/api/discount/validate',
|
||||
{'code': expired.code},
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.data['valid'])
|
||||
|
||||
@@ -29,6 +29,10 @@ def _is_usable(discount: DiscountCode) -> bool:
|
||||
class ValidateDiscountView(APIView):
|
||||
"""GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید"""
|
||||
|
||||
# این ویو مثل RedeemDiscountView به IsAuthenticated نیاز دارد؛ در غیر این
|
||||
# صورت کلاینت بدون احراز هویت میتواند کدهای تخفیف را probe کند.
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
|
||||
responses=ValidateResponseSerializer,
|
||||
@@ -49,24 +53,12 @@ class RedeemDiscountView(APIView):
|
||||
"""
|
||||
POST /api/discount/redeem — ثبت مصرف کد هنگام خرید
|
||||
|
||||
قبلاً فقط ValidateDiscountView چک max_uses/expires_at رو انجام میداد؛
|
||||
خود Redeem هیچکدوم از این محدودیتها رو دوباره چک نمیکرد و بین
|
||||
validate و redeem هم race condition ممکن بود (دو درخواست همزمان هر دو
|
||||
از سد شمارش رد بشن). اینجا:
|
||||
- همون شرطهای usable بودن (max_uses/expires_at) دوباره در لحظهی
|
||||
redeem چک میشه، نه فقط در validate.
|
||||
- با select_for_update روی DiscountCode، شمارش و ساخت Redemption در
|
||||
یک تراکنش اتمیک و با قفل ردیف انجام میشه تا از over-redemption
|
||||
همزمان جلوگیری بشه.
|
||||
- هر کاربر برای یک کد، حداکثر یکبار میتونه redeem کنه (unique
|
||||
constraint روی discount_code+user_id در سطح مدل/migration).
|
||||
شرطهای usable بودن (max_uses/expires_at) دوباره در لحظهی redeem زیر
|
||||
select_for_update چک میشود تا از over-redemption همزمان جلوگیری شود.
|
||||
هر کاربر برای یک کد حداکثر یکبار میتواند redeem کند (قید یکتایی مدل).
|
||||
|
||||
طبق سند، این اپ هیچ محاسبهی مبلغی رو دوباره خودش «ذخیره» نمیکنه؛ اما
|
||||
برای پر کردن discountAmount در پاسخ، باید مبلغ سفارش رو از یک جایی
|
||||
بخونه. چون هیچ اپ دیگهای به این اپ ارجاع نمیده (نه برعکس)، اینجا فقط
|
||||
یک خواندن با id از payment.models.Order انجام میشه — دقیقاً همون
|
||||
الگویی که progress/views.py برای خوندن نام کاربر از accounts.models.User
|
||||
استفاده کرده (import مستقیم + فیلتر با id، نه FK واقعی و نه join).
|
||||
discountAmount با یک خواندنِ مستقیم از payment.models.Order پر میشود
|
||||
(بدون FK واقعی، مطابق قرارداد پروژه برای ارجاع بین اپها).
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -80,10 +72,7 @@ class RedeemDiscountView(APIView):
|
||||
order_id = int(v['orderId'])
|
||||
|
||||
from payment.models import Order
|
||||
# ⚠️ اصلاح امنیتی: قبلاً مالکیتِ سفارش چک نمیشد، پس هر کاربر
|
||||
# لاگینکردهای میتونست با حدسزدن یک orderId، کد تخفیف رو روی
|
||||
# سفارشِ کاربر دیگری اعمال کنه. حالا سفارش باید متعلق به همون
|
||||
# کاربر احرازهویتشده باشه.
|
||||
# سفارش باید متعلق به همان کاربر احرازهویتشده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
|
||||
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
|
||||
|
||||
try:
|
||||
@@ -98,19 +87,8 @@ class RedeemDiscountView(APIView):
|
||||
if discount.redemptions.filter(user_id=request.user.id).exists():
|
||||
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کردهاید.'})
|
||||
|
||||
# ⚛️🔒 اصلاح race condition: قبلاً چکِ «order.status != 'pending'»
|
||||
# روی همون آبجکتِ order ای انجام میشد که *قبل* از ورود به این
|
||||
# atomic block و *بدون* قفل خونده شده بود (چند خط بالاتر، برای
|
||||
# چکِ مالکیت). یعنی اگه بینِ اون خواندنِ اولیه و این چک، یک
|
||||
# webhookِ پرداختِ همزمان روی همون سفارش paid میشد (که خودش با
|
||||
# select_for_update قفل میگیره)، این ویو همچنان مقدارِ
|
||||
# cached/قدیمیِ status='pending' رو میدید و اجازه میداد مبلغِ
|
||||
# یک سفارشِ *پرداختشده* بعد از پرداخت واقعی کم بشه — دقیقاً
|
||||
# همون کلاسِ باگی که apply_order_payment_result در
|
||||
# payment/services.py با قفلگرفتن *قبل* از چکِ already_paid
|
||||
# حلش کرده. حالا سفارش اول قفل میشه (select_for_update) و
|
||||
# چکِ pending بودن *بعد* از قفل، روی خودِ ردیفِ قفلشده
|
||||
# (order_locked) انجام میشه.
|
||||
# سفارش زیر قفل خوانده میشود و چک pending بودن *بعد* از قفل روی همان
|
||||
# ردیف انجام میشود، تا با یک webhook همزمان که سفارش را paid کرده race نکند.
|
||||
order_locked = Order.objects.select_for_update().get(pk=order.id)
|
||||
|
||||
if order_locked.status != 'pending':
|
||||
@@ -127,16 +105,11 @@ class RedeemDiscountView(APIView):
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
# ⚠️ اصلاح: قبلاً discountAmount فقط محاسبه و در پاسخ
|
||||
# برگردونده میشد ولی هیچوقت روی خودِ Order اعمال نمیشد،
|
||||
# یعنی جریان واقعی پرداخت مبلغ تخفیفنخورده رو دریافت میکرد.
|
||||
# حالا مبلغ سفارش واقعاً کم میشه تا مرحلهی پرداخت همین
|
||||
# مبلغ تخفیفخورده رو ببینه.
|
||||
# مبلغ سفارش واقعاً کم میشود تا مرحلهی پرداخت مبلغ تخفیفخورده را ببیند.
|
||||
order_locked.amount = max(order_locked.amount - discount_amount, 0)
|
||||
order_locked.save(update_fields=['amount'])
|
||||
except IntegrityError:
|
||||
# fallback در برابر race condition روی unique constraint، حتی
|
||||
# اگه به هر دلیلی قفل select_for_update رد بشه.
|
||||
# fallback برای رقابت همزمان روی قید یکتایی، حتی اگر قفل select_for_update رد شود.
|
||||
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفادهاش پر شده.'})
|
||||
|
||||
data = {'success': True, 'discountAmount': discount_amount}
|
||||
|
||||
Reference in New Issue
Block a user