feat: phone auth tests and fixes

This commit is contained in:
2026-03-13 23:48:40 +03:00
parent 38e5ece96f
commit 4026b94c3a
6 changed files with 116 additions and 11 deletions
+2 -2
View File
@@ -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)
+1
View File
@@ -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
@@ -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
+8 -3
View File
@@ -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)