feat: phone auth tests and fixes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user