From 4026b94c3a53c95deb8d466e641252168407fc1f Mon Sep 17 00:00:00 2001 From: mohd Date: Fri, 13 Mar 2026 23:48:40 +0300 Subject: [PATCH] feat: phone auth tests and fixes --- backend/apps/accounts/models.py | 4 +- backend/apps/accounts/serializers.py | 1 + .../tests/test_phone_first_enforcement.py | 71 +++++++++++++++++++ backend/apps/accounts/views.py | 11 ++- .../bookings/tests/test_booking_integrity.py | 20 +++++- .../apps/payments/tests/test_payments_flow.py | 20 +++++- 6 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 backend/apps/accounts/tests/test_phone_first_enforcement.py diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 1a45202..efc4673 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -17,8 +17,8 @@ class UserRole(models.TextChoices): class UserManager(BaseUserManager): def create_user(self, email=None, password=None, **extra_fields): phone_number = extra_fields.get("phone_number") - if not email and not phone_number: - raise ValueError("Email or phone number is required") + if not phone_number: + raise ValueError("Phone number is required") if email: email = self.normalize_email(email) user = self.model(email=email, **extra_fields) diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 66909f2..0b00b27 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, min_length=8) + phone_number = serializers.CharField(max_length=20, required=True) class Meta: model = User diff --git a/backend/apps/accounts/tests/test_phone_first_enforcement.py b/backend/apps/accounts/tests/test_phone_first_enforcement.py new file mode 100644 index 0000000..09e394a --- /dev/null +++ b/backend/apps/accounts/tests/test_phone_first_enforcement.py @@ -0,0 +1,71 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from apps.accounts.models import OtpPurpose, PhoneOTP, User + + +@pytest.mark.django_db +def test_create_user_rejects_email_only_identity(): + # Phone-first invariant: do not allow creating users without phone_number. + with pytest.raises(ValueError): + User.objects.create_user(email="email-only@example.com") + + +@pytest.mark.django_db +def test_register_requires_phone_number(client): + # Public registration must keep phone as required identifier. + response = client.post( + reverse("register"), + {"email": "new@example.com", "password": "StrongPass123"}, + content_type="application/json", + ) + + assert response.status_code == 400 + assert "phone_number" in response.json() + + +@pytest.mark.django_db +def test_phone_auth_verify_rejects_verify_purpose_otp(client): + user = User.objects.create_user(phone_number="+966512345678") + otp = PhoneOTP.objects.create( + phone_number=user.phone_number, + channel="sms", + purpose=OtpPurpose.VERIFY, + provider="console", + code_hash="not-used", + expires_at=PhoneOTP.expiry_at(), + ) + + # Purpose boundary: /phone/verify must only accept auth OTP requests. + with patch("apps.accounts.views.verify_otp", return_value=True): + response = client.post( + reverse("phone_auth_verify"), + {"request_id": str(otp.id), "code": "123456"}, + content_type="application/json", + ) + + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_otp_verify_rejects_auth_purpose_otp(client): + otp = PhoneOTP.objects.create( + phone_number="+966512345678", + channel="sms", + purpose=OtpPurpose.AUTH, + provider="console", + code_hash="not-used", + expires_at=PhoneOTP.expiry_at(), + ) + + # Purpose boundary: /otp/verify must only accept verify OTP requests. + with patch("apps.accounts.views.verify_otp", return_value=True): + response = client.post( + reverse("otp_verify"), + {"request_id": str(otp.id), "code": "123456"}, + content_type="application/json", + ) + + assert response.status_code == 400 diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index dedfd13..6861403 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,5 +1,4 @@ from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions, status from rest_framework.response import Response from django.utils.translation import gettext as _ @@ -70,7 +69,10 @@ class OTPVerifyView(APIView): serializer = OTPVerifySerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - otp = get_object_or_404(PhoneOTP, id=data["request_id"]) + # Purpose isolation: verification endpoint accepts only verify-purpose OTPs. + otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.VERIFY).first() + if not otp: + return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) if not verify_otp(otp, data["code"]): return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) @@ -133,7 +135,10 @@ class PhoneAuthVerifyView(APIView): serializer = PhoneAuthVerifySerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - otp = get_object_or_404(PhoneOTP, id=data["request_id"]) + # Purpose isolation: login endpoint accepts only auth-purpose OTPs. + otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.AUTH).first() + if not otp: + return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) if not verify_otp(otp, data["code"]): return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/apps/bookings/tests/test_booking_integrity.py b/backend/apps/bookings/tests/test_booking_integrity.py index d34618e..9940c3c 100644 --- a/backend/apps/bookings/tests/test_booking_integrity.py +++ b/backend/apps/bookings/tests/test_booking_integrity.py @@ -12,9 +12,23 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile @pytest.fixture def base_entities(): - owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) - customer = User.objects.create_user(email="customer@example.com", password="pass") - staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) + owner = User.objects.create_user( + email="owner@example.com", + password="pass", + role=UserRole.MANAGER, + phone_number="+966500000001", + ) + customer = User.objects.create_user( + email="customer@example.com", + password="pass", + phone_number="+966500000002", + ) + staff_user = User.objects.create_user( + email="staff@example.com", + password="pass", + role=UserRole.STAFF, + phone_number="+966500000003", + ) salon = Salon.objects.create( owner=owner, diff --git a/backend/apps/payments/tests/test_payments_flow.py b/backend/apps/payments/tests/test_payments_flow.py index bdbdeda..364407c 100644 --- a/backend/apps/payments/tests/test_payments_flow.py +++ b/backend/apps/payments/tests/test_payments_flow.py @@ -15,9 +15,23 @@ from apps.salons.models import Salon, Service, StaffProfile @pytest.fixture def booking_entities(): - owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) - customer = User.objects.create_user(email="customer@example.com", password="pass") - staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) + owner = User.objects.create_user( + email="owner@example.com", + password="pass", + role=UserRole.MANAGER, + phone_number="+966500000011", + ) + customer = User.objects.create_user( + email="customer@example.com", + password="pass", + phone_number="+966500000012", + ) + staff_user = User.objects.create_user( + email="staff@example.com", + password="pass", + role=UserRole.STAFF, + phone_number="+966500000013", + ) salon = Salon.objects.create( owner=owner,